跳到主要内容

用户态与内核态是啥?

因为上操作系统课程的时候在制作游戏,所以没有好好学习这块知识,因此这里补充学习 文章转载自 从根上理解用户态与内核态

小故事

张三是某科技公司的初级Java开发工程师(低权限),目前在15楼办公码代码,公司提供的资源仅有一套电脑(用户态),张三想着这一线的房价,倍感压力山大,于是给自己定下一个目标,一定要做技术总监,在一线扎根

奋斗B张三,奋斗5年终于当上了技术总监(高权限),之后张三搬到30楼,可以随时向资源部(系统调用)申请公司各种资源与获取公司的机密信息(内核态),所谓是走上人生巅峰。

通过这个故事,我们发现,低权限的资源范围较小,高权限的资源范围更大,所谓的「用户态与内核态只是不同权限的资源范围」。

CPU 指令集权限

在说用户态与内核态之前,有必要说一下 CPU 指令集,指令集是 CPU 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令,而非常非常多的 CPU 指令 在一起,可以组成一个、甚至多个集合,指令的集合叫 CPU 指令集。

同时 CPU 指令集 有权限分级,大家试想,CPU 指令集 可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。

而对于硬件的操作是非常复杂的,参数众多,出问题的几率相当大,必须谨慎的进行操作,对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 CPU 指令集。

针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集设置了权限,不同级别权限能使用的 CPU 指令集 是有限的,以 Inter CPU 为例,Inter把 CPU 指令集 操作的权限由高到低划为4级:

  • ring 0
  • ring 1
  • ring 2
  • ring 3

其中 ring 0 权限最高,可以使用所有 CPU 指令集,ring 3 权限最低,仅能使用常规 CPU 指令集,不能使用操作硬件资源的 CPU 指令集,比如 IO 读写、网卡访问、申请内存都不行,注意:Linux 系统仅采用 ring 0 和 ring 3 这 2 个权限。

高情商

  • ring 0 被叫做内核态,完全在操作系统内核中运行
  • ring 3 被叫做用户态,在应用程序中运行

低情商

  • 执行内核空间的代码,具有 ring 0 保护级别,有对硬件的所有操作权限,可以执行所有 CPU 指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机

  • 在用户模式下,具有 ring 3 保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发生崩溃也是可以恢复的,在电脑上大部分程序都是在,用户模式下运行的

用户态与内核态

通关了 CPU 指令集权限,现在再说用户态与内核态就十分简单了,用户态与内核态的概念就是 CPU 指令集权限的区别,进程中要读写 IO,必然会用到 ring 0 级别的 CPU 指令集,而此时 CPU 的指令集操作权限只有 ring 3,为了可以操作 ring 0 级别的 CPU 指令集,CPU 需要切换指令集操作权限级别为 ring 0,CPU 再执行相应的 ring 0 级别的 CPU 指令集(内核代码),执行的内核代码会使用当前进程的内核栈。

PS:每个进程都有两个栈,分别是用户栈与内核栈,对应用户态与内核态的使用

用户态与内核态的空间

在内存资源上的使用,操作系统对用户态与内核态也做了限制,每个进程创建都会分配「虚拟空间地址」,以 Linux32 位操作系统为例,它的寻址空间范围是 4G(2 的 32 次方),而操作系统会把虚拟控制地址划分为两部分,一部分为内核空间,另一部分为用户空间,高位的 1G(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,而低位的 3G(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用。

  • 用户态:只能操作 0-3G 范围的低位虚拟空间地址
  • 内核态:0-4G 范围的虚拟空间地址都可以操作,尤其是对 3-4G 范围的高位虚拟空间地址必须由内核态去操作

补充:3G-4G 部分大家是共享的(指所有进程的内核态逻辑地址是共享同一块内存地址),是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据

每个进程的 4G 虚拟空间地址,高位 1G 都是一样的,即内核空间。只有剩余的 3G 才归进程自己使用,换句话说就是, 高位 1G 的内核空间是被所有进程共享的!

最后做个小结,我们通过指令集权限区分用户态和内核态,还限制了内存资源的使用,操作系统为用户态与内核态划分了两块内存空间,给它们对应的指令集使用

用户态与内核态的切换

相信大家都听过这样的话 「用户态和内核态切换的开销大」,但是它的开销大在那里呢?简单点来说有下面几点

  • 保留用户态现场(上下文、寄存器、用户栈等)
  • 复制用户态参数,用户栈切到内核栈,进入内核态
  • 额外的检查(因为内核代码对用户不信任)
  • 执行内核态代码
  • 复制内核态代码执行结果,回到用户态
  • 恢复用户态现场(上下文、寄存器、用户栈等)

实际上操作系统会比上述的更复杂,这里只是个大概,我们可以发现一次切换经历了「用户态 -> 内核态 -> 用户态」。

用户态要主动切换到内核态,那必须要有入口才行,实际上内核态是提供了统一的入口,下面是 Linux 整体架构图

从上图我们可以看出来通过系统调用将 Linux 整个体系分为用户态和内核态,为了使应用程序访问到内核的资源,如 CPU、内存、I/O,内核必须提供一组通用的访问接口,这些接口就叫系统调用。

库函数就是屏蔽这些复杂的底层实现细节,减轻程序员的负担,从而更加关注上层的逻辑实现,它对系统调用进行封装,提供简单的基本接口给程序员。

Shell 顾名思义,就是外壳的意思,就好像把内核包裹起来的外壳,它是一种特殊的应用程序,俗称命令行。Shell 也是可编程的,它有标准的 Shell 语法,符合其语法的文本叫 Shell 脚本,很多人都会用 Shell 脚本实现一些常用的功能,可以提高工作效率。

最后来说说,什么情况会导致用户态到内核态切换

1、系统调用:用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如 fork() 就是一个创建新进程的系统调用,系统调用的机制核心使用了操作系统为用户特别开放的一个中断来实现,如 Linux 的 int 80h 中断,也可以称为软中断

2、异常:当 CPU 在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常

3、中断:当 CPU 在执行用户态的进程时,外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

用户态和内核态的切换实例

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

代码通常如下,一般会需要两个系统调用:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

代码很简单,虽然就两行代码,但是这里面发生了不少的事情。

首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read(),一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

补充:「用户态和内核态切换的开销大」,但是它的开销大在那里呢?简单点来说有下面几点

  • 保留用户态现场(上下文、寄存器、用户栈等)
  • 复制用户态参数,用户栈切到内核栈,进入内核态
  • 额外的检查(因为内核代码对用户不信任)
  • 执行内核态代码
  • 复制内核态代码执行结果,回到用户态
  • 恢复用户态现场(上下文、寄存器、用户栈等)

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就 需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。

为什么需要内核缓存

总结来说,常规文件操作 为了提高读写效率和保护磁盘,使用了页缓存机制(PageCache)。

但是这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。

这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的 buffer 在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。

所以零拷贝那篇博客还讲了跳过内核缓存的直接 IO,具体看这篇博文 Linux I/O 原理和 Zero-copy 技术全面揭秘 - 潘少的文章 - 知乎

Reference

原来 8 张图,就可以搞懂「零拷贝」了